Skip to content

feat(capsule-cli): add verify subcommand for PCR attestation check#1

Open
mvanhorn wants to merge 2 commits into
sparsity-xyz:mainfrom
mvanhorn:feat/capsule-cli-verify-pcr
Open

feat(capsule-cli): add verify subcommand for PCR attestation check#1
mvanhorn wants to merge 2 commits into
sparsity-xyz:mainfrom
mvanhorn:feat/capsule-cli-verify-pcr

Conversation

@mvanhorn

@mvanhorn mvanhorn commented May 3, 2026

Copy link
Copy Markdown

Summary

Adds a new capsule-cli verify subcommand that compares an EIF's PCR measurements against a build attestation. Closes the trust loop App Hub opens: builds emit a signed build-attestation.json with measurements, but until now there was no client-side tool to confirm a downloaded release image actually matches its attestation.

Why this matters

Today, a downstream consumer of a Nova release image has to trust App Hub's signature on the attestation file. There's no local way to re-derive measurements from the EIF and check them.

  • App Hub already extracts PCR0/1/2 in app-hub/.github/workflows/build-on-merge.yml#L450-L483 and emits them into build-attestation.json. The values are consumed on-chain via App Registry, but no CLI surfaces them locally for inspection.
  • The upstream lineage shows persistent demand. edgebitio/enclaver#238 ("Reproducible Enclaver builds") was filed in the original codebase that capsule-cli forks from (see TODO(russell_h) markers in src/build.rs#L152) and was never closed.
  • Comparable projects ship this capability: Marlin Oyster's oyster-cvm verify and Phala dstack's dstack-verify. capsule-cli was the missing peer.

Demo

capsule-cli verify demo

The reel shows two things: capsule-cli verify --help (subcommand surface with all three flags) and cargo test verify::tests --quiet (8 of 8 unit tests passing).

A real-EIF happy path requires nitro-cli describe-eif and a Nitro EC2 host, neither available in this environment. The wiring from verify to the existing NitroCLI::describe_eif() path is exercised through the run_verify() integration but the actual subprocess call is not in the demo. Maintainers with Nitro infrastructure can sanity-check the round trip on a real release image.

Changes

New file:

  • enclave-capsule/capsule-cli/src/verify.rs (270 lines including tests). Permissive attestation parser (AttestationMeasurements::from_file), PCR comparison (compare), pretty-printed report, async run_verify that calls the existing NitroCLI::describe_eif().

Modified:

  • enclave-capsule/capsule-cli/src/lib.rs: pub mod verify;.
  • enclave-capsule/capsule-cli/src/bin/capsule-cli/main.rs: new Verify clap subcommand variant and match arm. Exit 0 on full match, 1 on mismatch, 2 on file/parse errors.

No new dependencies. tempfile = "3.23" was already in Cargo.toml; the verify tests use it.

The attestation parser is permissive about JSON shape because App Hub emits pcr0 lowercase in some workflows (build-on-merge.yml#L562), PCR0 uppercase in others, and nitro-cli describe-eif outputs the nested {"measurements": {"PCR0": ...}} shape. The parser accepts all three. Hex comparison is case-insensitive and tolerates a leading 0x.

Testing

  • cargo fmt --check: clean.
  • cargo clippy --all-targets -p capsule-cli -- -D warnings: clean (only the pre-existing num-bigint-dig future-incompat warning, unrelated).
  • cargo test -p capsule-cli verify::tests: 8 of 8 pass. Coverage: uppercase / lowercase / nested attestation parsing, missing-PCR rejection, normalization (case + 0x prefix), mismatch detection, PCR8 inclusion when present.

What the tests do not cover end-to-end: the nitro-cli describe-eif invocation itself. That requires the AWS Nitro CLI plus a real EIF on disk. Happy to add a feature-gated integration test if you have a fixture path you'd like used.

Open questions

  1. Should the EIF arg also accept a release image (the self-extracting Docker image with an embedded EIF), and extract the EIF first? Today it expects the raw .eif. Easy follow-up.
  2. Is the JSON shape preference one of the three the parser handles, or should one be the canonical and the others removed from the parser? I kept it permissive for now since both shapes appear in the existing repo.
  3. Want a --rebuild mode (clone source at the pinned ref, re-run the App Hub build, compare PCRs) as a follow-up PR? That's the harder half of enclaver#238.

I read through enclave-capsule/capsule-cli/src/nitro_cli.rs:110-137 to confirm describe_eif returns EIFInfo { measurements: EIFMeasurements } with pcr0/1/2/8 as String, so the comparison stays in the existing type system rather than introducing a parallel one.

The verify subcommand reads a build-attestation.json file and compares its
PCR0/1/2 (and optionally PCR8) against the actual EIF measurements via
nitro-cli describe-eif. This closes the trust loop App Hub opens: builds
emit signed attestations, but until now there was no client-side tool to
confirm a release image's measurements actually match its attestation.

The attestation parser is permissive about JSON shape:
- {"PCR0": ...} (uppercase, used in some workflows)
- {"pcr0": ...} (lowercase, used in app-hub build-on-merge.yml)
- {"measurements": {"PCR0": ...}} (nested, default describe-eif output)

Hex comparison is case-insensitive and tolerates a leading 0x prefix.

Exit codes: 0 on full match, 1 on mismatch, 2 on file/parse errors.
@zfdang

zfdang commented May 6, 2026

Copy link
Copy Markdown
Contributor

PR #1 Review: feat(capsule-cli): add verify subcommand for PCR attestation check

Repo: sparsity-xyz/nova-stack
Author: @mvanhorn
Branch: feat/capsule-cli-verify-pcr -> main
Verdict: Request changes

Summary

This is a useful addition: a local capsule-cli verify command closes an important gap between App Hub build attestations and downloadable EIF artifacts. The implementation is small, reuses nitro_cli::EIFMeasurements, adds no new dependencies, and has focused tests for normalization and basic parser shapes.

One blocker remains: the parser does not accept the production build-attestation.json shape emitted by app-hub/.github/workflows/build-on-merge.yml. In the real file, PCRs live under an enclave object. The PR parser only accepts top-level PCR fields or a measurements object, so a real App Hub attestation fails with attestation missing PCR0.

I independently reproduced this with a minimal real-schema fixture:

{"schema_version":"1.0","enclave":{"pcr0":"aa","pcr1":"bb","pcr2":"cc"}}

Running capsule-cli verify -e /tmp/nonexistent.eif -a <fixture> --quiet fails before reaching nitro-cli, which confirms the parser issue.

Blocker

B1. Parser does not match the real build-attestation.json schema

File: enclave-capsule/capsule-cli/src/verify.rs

RawAttestation currently supports:

  • top-level PCR0 / PCR1 / PCR2
  • top-level pcr0 / pcr1 / pcr2
  • nested measurements

The production App Hub workflow emits:

{
  "schema_version": "1.0",
  "type": "https://sparsity.cloud/nova/build-attestation/v1",
  "source": { "...": "..." },
  "enclave": {
    "pcr0": "...",
    "pcr1": "...",
    "pcr2": "..."
  },
  "build": { "...": "..." },
  "image": { "...": "..." }
}

The PR description cites build-on-merge.yml#L562 as lowercase PCR support, but that field is inside "enclave", not at the top level.

Suggested fix:

  • Add an enclave: Option<NestedMeasurements> field to RawAttestation.
  • Prefer enclave as the canonical Nova App Hub shape.
  • Keep measurements as a compatibility path for nitro-cli describe-eif-style JSON.
  • Add a unit test for the real schema:
{"schema_version":"1.0","enclave":{"pcr0":"aa","pcr1":"bb","pcr2":"cc"}}

Non-Blocking Notes

  • ANSI output: verify.rs emits color escape sequences unconditionally. Use std::io::IsTerminal or pad uncolored labels before applying color so redirected output and table alignment stay clean.
  • Exit handling: main.rs calls std::process::exit inside the Verify match arm. Prefer returning an exit code from the command path and exiting once at the outer boundary.
  • PCR8 visibility: if the EIF reports PCR8 but the attestation omits it, the current report silently skips PCR8. Consider showing a non-failing "not asserted" row.
  • run_verify coverage: tests cover parsing and comparison but not the read-attestation -> describe-eif -> compare -> exit-code flow. Extracting the decision logic would make this easy to unit test without Nitro infrastructure.
  • Help text: --eif says it accepts "the EIF file (or release image's embedded EIF)", but the code passes the path directly to nitro-cli describe-eif --eif-path. Either narrow the help text to raw EIF files or implement release-image EIF extraction.
  • Empty PCR guard: compare_pcr relies on !exp_n.is_empty() to prevent empty strings from matching. A short comment or parse-time rejection would make that invariant clearer.

What Looks Good

  • No new dependencies.
  • Exit code intent is clear: 0 match, 1 mismatch, 2 file or parse errors.
  • Hex normalization handles case and 0x / 0X prefixes.
  • Error contexts include the attestation file path.
  • The implementation reuses existing Nitro CLI data types instead of adding a parallel measurement model.

Recommendation

Address B1 and add the real-schema test before merging. The non-blocking notes can be handled in this PR or follow-ups.

@voidcenter

Copy link
Copy Markdown
Contributor

cc @mvanhorn

Adds enclave: Option<NestedMeasurements> to RawAttestation so
capsule-cli verify accepts the build-attestation.json shape emitted
by app-hub/.github/workflows/build-on-merge.yml. Enclave is preferred
over measurements; top-level PCR fields remain a fallback. Adds a
unit test covering the production fixture from the review.
@mvanhorn

Copy link
Copy Markdown
Author

@zfdang addressed B1 in 3277f1e: added enclave: Option<NestedMeasurements> to RawAttestation, prefer it over measurements, and added the parses_production_enclave_schema unit test with the exact fixture from your review ({"schema_version":"1.0","enclave":{"pcr0":"aa","pcr1":"bb","pcr2":"cc"}}). All existing tests still pass; cargo build, cargo test, cargo clippy --all-targets -- -D warnings are clean.

The non-blocking notes (ANSI gating, exit-code refactor, PCR8 visibility, run_verify coverage, help text, empty-PCR guard) are tracked for follow-up; happy to roll them into this PR if you'd prefer one trip.

Thanks for the careful review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants